﻿/* Copyright 2009 HPDI, LLC
 * 
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 * 
 *     http://www.apache.org/licenses/LICENSE-2.0
 * 
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Diagnostics;
using System.IO;
using System.Text;
using System.Threading;
using System.Linq;

namespace Hpdi.Vss2AccuRev
{
    /// <summary>
    /// Wraps execution of Git and implements the common Git commands.
    /// </summary>
    /// <author>Trevor Robinson</author>
    class AccuRevWrapper
    {
        private readonly string physicalPath;
        private readonly Logger logger;
        private readonly Stopwatch stopwatch = new Stopwatch();
        private string accurevExecutable = "accurev.exe";
        private string accurevInitialArguments = null;
        private bool shellQuoting = false;
        private Encoding commitEncoding = Encoding.UTF8;
        private LinkedList<string> pendingAdds = new LinkedList<string>();
        private LinkedList<string> pendingRecovers = new LinkedList<string>();
        private LinkedList<PendingRename> pendingRenames = new LinkedList<PendingRename>();
        private LinkedList<string> pendingDeletes = new LinkedList<string>();
        private string keepComment = string.Empty;

        private class PendingSingleFileOperation
        {
            public string FilePath { get; set; }
            public PendingSingleFileOperation(string path)
            {
                FilePath = path;
            }
        }

        private class PendingRename
        {
            public string OldFilePath { get; set; }
            public string NewFilePath { get; set; }
            public PendingRename(string oldPath, string newPath)
            {
                OldFilePath = oldPath;
                NewFilePath = newPath;
            }
        }

        public TimeSpan ElapsedTime
        {
            get { return stopwatch.Elapsed; }
        }

        public string AccuRevExecutable
        {
            get { return accurevExecutable; }
            set { accurevExecutable = value; }
        }

        public string AccuRevInitialArguments
        {
            get { return accurevInitialArguments; }
            set { accurevInitialArguments = value; }
        }

        public bool ShellQuoting
        {
            get { return shellQuoting; }
            set { shellQuoting = value; }
        }

        public Encoding CommitEncoding
        {
            get { return commitEncoding; }
            set { commitEncoding = value; }
        }

        public string KeepComment {
            get
            {
                return keepComment;
            }
            set
            {
                if (keepComment.IndexOfAny(new char[] { '\r', '\n', '\"', '<', '>', '|', '*', '?' }) >= 0)
                    throw new ArgumentException("KeepComment may not contain newlines or command line directive characters");
                keepComment = value;
            }
        }

        public AccuRevWrapper(string physicalPath, Logger logger)
        {
            this.physicalPath = physicalPath;
            this.logger = logger;
        }

        public bool FindExecutable()
        {
            string foundPath;
            if (System.IO.File.Exists(accurevExecutable))
            {
                return true;
            }
            accurevExecutable = System.IO.Path.GetFileName(accurevExecutable);
            string defaultPath = System.IO.Path.Combine(Environment.GetEnvironmentVariable("ProgramFiles(x86)"), 
                "AccuRev" + System.IO.Path.DirectorySeparatorChar + "bin");
            if (System.IO.File.Exists(System.IO.Path.Combine(defaultPath, accurevExecutable)))
            {
                accurevExecutable = System.IO.Path.Combine(defaultPath, accurevExecutable);
                return true;
            }
            if (FindInPathVar(accurevExecutable, out foundPath))
            {
                accurevExecutable = foundPath;
                accurevInitialArguments = null;
                shellQuoting = false;
                return true;
            }
            return false;
        }

        /*public bool ApplyAndKeep(string sourceName, string user, DateTime time)
        {
            string comment = string.Format("Source: {0}; User: {1}; Time: {2}", sourceName, user, time.ToString("s"));
            if (!ApplyPendingOpertions(comment))
                return false;
            string args = "keep -m";
            TempFile commentFile;
            AddComment(comment, ref args, out commentFile);
            using (commentFile)
            {
                var startInfo = GetStartInfo(args);
                return ExecuteUnless(startInfo, "Keep failed");
            }
        }*/

        /*public void Add(string path)
        {
            if (pendingRecovers.Contains(path))
                return;
            if (pendingRenames.Where(r => string.Compare(r.NewFilePath, path, true) == 0).Any())
                return;
            if (pendingDeletes.Remove(path))
                return;
            var affectedRename = pendingRenames.Where(r => string.Compare(r.OldFilePath, path, true) == 0).SingleOrDefault();
            if (affectedRename != null)
            {
                pendingAdds.AddLast(affectedRename.NewFilePath);
                pendingRenames.Remove(affectedRename);
                return;
            }
            pendingAdds.AddLast(path);
        }*/

        public bool AddAndKeep(string path)
        {
            logger.WriteLine("add {0}", path);
            var cmd = "add -c " + Quote(KeepComment);
            if (IsUnicode(path))
                cmd += " -E ptext";
            cmd += " " + Quote(path);
            var startInfo = GetStartInfo(cmd);
            try
            {
                return ExecuteUnless(startInfo, "add failed for " + Quote(path));
            }
            catch (ProcessExitException ex)
            {
                if (ex.Stderr.StartsWith("Element is defunct: "))
                {
                    logger.WriteLine("Retrying add as recover");
                    byte[] oldContent = System.IO.File.ReadAllBytes(path);
                    File.Delete(path);
                    if (!RecoverAndKeep(path))
                        return false;
                    byte[] newContent = System.IO.File.ReadAllBytes(path);
                    if (!oldContent.SequenceEqual(newContent))
                    {
                        System.IO.File.WriteAllBytes(path, oldContent);
                        logger.WriteLine("Recovered file contents differ; applying keep");
                        return Keep(path);
                    }
                    return true;
                }
                else if (ex.Stderr.StartsWith("Element already exists: "))
                {
                    logger.WriteLine("Retrying add as Keep");
                    return Keep(path);
                }
                throw;
            }
        }

        public bool RemoveAndKeep(string path)
        {
            logger.WriteLine("defunct {0}", path);
            var startInfo = GetStartInfo("defunct -c " + Quote(KeepComment) + " " + Quote(path));
            return ExecuteUnless(startInfo, "defunct failed for " + Quote(path));
        }

        public bool RecoverAndKeep(string path)
        {
            logger.WriteLine("undefunct {0}", path);
            var startInfo = GetStartInfo("undefunct -c " + Quote(KeepComment) + " " + Quote(path));
            return ExecuteUnless(startInfo, "undefunct failed for " + Quote(path));
        }

        public void RenameAndKeep(string oldPath, string newPath)
        {
            logger.WriteLine("move {0} {1}", oldPath, newPath);
            var startInfo = GetStartInfo(string.Format("move -c {0} {1} {2}", Quote(keepComment), Quote(oldPath), Quote(newPath)));
            ExecuteUnless(startInfo, "undefunct failed for " + Quote(oldPath) + " -> " + Quote(newPath));
        }

        public bool Keep(string path)
        {
            logger.WriteLine("keep {0}", path);
            var cmd = "keep -c " + Quote(KeepComment);
            if (IsUnicode(path))
                cmd += " -E ptext";
            cmd += " " + Quote(path);
            var startInfo = GetStartInfo(cmd);
            return ExecuteUnless(startInfo, "keep failed for " + Quote(path));
        }

        private void ClearPending()
        {
            pendingAdds.Clear();
            pendingDeletes.Clear();
            pendingRenames.Clear();
            pendingRecovers.Clear();
        }

        /*private bool ApplyPendingOpertions(string comment)
        {
            string args;
            TempFile commentFile;

            if (pendingAdds.Any())
            {
                using (TempFile listFile = new TempFile())
                {
                    logger.WriteLine("add list:{0}{1}", Environment.NewLine, string.Join(Environment.NewLine,pendingAdds.ToArray()));
                    listFile.Write(string.Join(Environment.NewLine, pendingAdds.ToArray()), Encoding.UTF8);
                    args = "add";
                    AddComment(comment, ref args, out commentFile);
                    args += " -l " + Quote(listFile.Name);
                    using (commentFile)
                    {
                        var startInfo = GetStartInfo(args);
                        if (!ExecuteUnless(startInfo, "add failed with list " + Quote(listFile.Name)))
                            return false;
                    }
                }
                pendingAdds.Clear();
            }

            if (pendingRecovers.Any())
            {
                using (TempFile listFile = new TempFile())
                {
                    logger.WriteLine("undefunct list:{0}{1}", Environment.NewLine, string.Join(Environment.NewLine, pendingRecovers.ToArray()));
                    listFile.Write(string.Join(Environment.NewLine, pendingRecovers.ToArray()), Encoding.UTF8);
                    args = "undefunct";
                    AddComment(comment, ref args, out commentFile);
                    args += " -l " + Quote(listFile.Name);
                    using (commentFile)
                    {
                        var startInfo = GetStartInfo(args);
                        if (!ExecuteUnless(startInfo, "undefunct failed with list " + Quote(listFile.Name)))
                            return false;
                    }
                }
                pendingRecovers.Clear();
            }

            if (pendingDeletes.Any())
            {
                using (TempFile listFile = new TempFile())
                {
                    logger.WriteLine("defunct list:{0}{1}", Environment.NewLine, string.Join(Environment.NewLine, pendingDeletes.ToArray()));
                    listFile.Write(string.Join(Environment.NewLine, pendingDeletes.ToArray()), Encoding.UTF8);
                    args = "defunct";
                    AddComment(comment, ref args, out commentFile);
                    args += " -l " + Quote(listFile.Name);
                    using (commentFile)
                    {
                        var startInfo = GetStartInfo(args);
                        if (!ExecuteUnless(startInfo, "defunct failed with list " + Quote(listFile.Name)))
                            return false;
                    }
                }
                pendingDeletes.Clear();
            }

            if (pendingRenames.Any())
            {
                foreach (PendingRename op in pendingRenames)
                {
                    args = "move";
                    AddComment(comment, ref args, out commentFile);
                    args += " " + Quote(op.OldFilePath) + " " + Quote(op.NewFilePath);
                    using (commentFile)
                    {
                        var startInfo = GetStartInfo(args);
                        if (!ExecuteUnless(startInfo, "move from " + Quote(op.OldFilePath) + " to " + Quote(op.NewFilePath) + " failed"))
                            return false;
                    }
                }
                pendingRenames.Clear();
            }
            return true;
        }

        public void Remove(string path)
        {
            if (pendingAdds.Remove(path))
                return;
            var affectedRename = pendingRenames.Where(r => string.Compare(r.NewFilePath, path, true) == 0).SingleOrDefault();
            if ((affectedRename != null) && (string.Compare(affectedRename.OldFilePath, affectedRename.NewFilePath, true) != 0))
            {
                pendingDeletes.AddLast(affectedRename.OldFilePath);
                pendingRenames.Remove(affectedRename);
            }
            else
                pendingDeletes.AddLast(path);
        }

        public void Move(string sourcePath, string destPath)
        {
            var affectedAdd = pendingAdds.Find(sourcePath);
            if (affectedAdd != null)
            {
                affectedAdd.Value = destPath;
                return;
            }
            var affectedRename = pendingRenames.Where(r => string.Compare(r.NewFilePath, sourcePath, true) == 0).SingleOrDefault();
            if (affectedRename != null)
            {
                affectedRename.NewFilePath = destPath;
                return;
            }
            pendingRenames.AddLast(new PendingRename(sourcePath, destPath));
        }

        public void Recover(string path)
        {
            if (pendingDeletes.Remove(path))
                return;
            pendingRecovers.AddLast(path);
        }*/

        class TempFile : IDisposable
        {
            private readonly string name;
            private readonly StreamWriter writer;
            private readonly Encoding encoding;

            public string Name
            {
                get { return name; }
            }

            public TempFile(Encoding encoding)
            {
                name = Path.GetTempFileName();
                writer = new StreamWriter(name, false, encoding);

                this.encoding = encoding;
            }

            public void Write(string text)
            {
                writer.Write(text);
                writer.Flush();
            }

            public void Dispose()
            {
                if (writer != null)
                {
                    writer.Dispose();
                }
                if (name != null)
                {
                    File.Delete(name);
                }
            }
        }

        private void AddComment(string comment, ref string args, out TempFile tempFile)
        {
            tempFile = null;
            if (!string.IsNullOrEmpty(comment))
            {
                // need to use a temporary file to specify the comment when not
                // using the system default code page or it contains newlines
                if (commitEncoding.CodePage != Encoding.Default.CodePage || comment.IndexOf('\n') >= 0)
                {
                    logger.WriteLine("Generating temp file for comment: {0}", comment);
                    tempFile = new TempFile(commitEncoding);
                    tempFile.Write(comment);

                    // temporary path might contain spaces (e.g. "Documents and Settings")
                    args += " -c @" + Quote(tempFile.Name);
                }
                else
                {
                    args += " -c " + Quote(comment);
                }
            }
        }

        public bool Commit(string authorName, string authorEmail, string comment, DateTime localTime)
        {
            TempFile commentFile;

            var args = "promote -p";
            AddComment(comment, ref args, out commentFile);

            using (commentFile)
            {
                var startInfo = GetStartInfo(args);
                startInfo.EnvironmentVariables["GIT_AUTHOR_NAME"] = authorName;
                startInfo.EnvironmentVariables["GIT_AUTHOR_EMAIL"] = authorEmail;
                startInfo.EnvironmentVariables["GIT_AUTHOR_DATE"] = GetUtcTimeString(localTime);

                // also setting the committer is supposedly useful for converting to Mercurial
                startInfo.EnvironmentVariables["GIT_COMMITTER_NAME"] = authorName;
                startInfo.EnvironmentVariables["GIT_COMMITTER_EMAIL"] = authorEmail;
                startInfo.EnvironmentVariables["GIT_COMMITTER_DATE"] = GetUtcTimeString(localTime);

                // ignore empty commits, since they are non-trivial to detect
                // (e.g. when renaming a directory)
                return ExecuteUnless(startInfo, "nothing to commit");
            }
        }

        public void Tag(string name, string taggerName, string taggerEmail, string comment, DateTime localTime)
        {
            return; // not implemented
            TempFile commentFile;

            var args = "tag";
            AddComment(comment, ref args, out commentFile);

            // tag names are not quoted because they cannot contain whitespace or quotes
            args += " -- " + name;

            using (commentFile)
            {
                var startInfo = GetStartInfo(args);
                startInfo.EnvironmentVariables["GIT_COMMITTER_NAME"] = taggerName;
                startInfo.EnvironmentVariables["GIT_COMMITTER_EMAIL"] = taggerEmail;
                startInfo.EnvironmentVariables["GIT_COMMITTER_DATE"] = GetUtcTimeString(localTime);

                ExecuteUnless(startInfo, null);
            }
        }

        private static string GetUtcTimeString(DateTime localTime)
        {
            // convert local time to UTC based on whether DST was in effect at the time
            var utcTime = TimeZoneInfo.ConvertTimeToUtc(localTime);

            // format time according to ISO 8601 (avoiding locale-dependent month/day names)
            return utcTime.ToString("yyyy'-'MM'-'dd HH':'mm':'ss +0000");
        }

        private void AccuRevExec(string args)
        {
            var startInfo = GetStartInfo(args);
            ExecuteUnless(startInfo, null);
        }

        private ProcessStartInfo GetStartInfo(string args)
        {
            if (!string.IsNullOrEmpty(accurevInitialArguments))
            {
                args = accurevInitialArguments + " " + args;
            }

            var startInfo = new ProcessStartInfo(accurevExecutable, args);
            startInfo.UseShellExecute = false;
            startInfo.RedirectStandardInput = true;
            startInfo.RedirectStandardOutput = true;
            startInfo.RedirectStandardError = true;
            startInfo.WorkingDirectory = physicalPath;
            startInfo.CreateNoWindow = true;
            return startInfo;
        }

        private bool ExecuteUnless(ProcessStartInfo startInfo, string unless)
        {
            string stdout, stderr;
            int exitCode = Execute(startInfo, out stdout, out stderr);
            if (exitCode != 0)
            {
                if (string.IsNullOrEmpty(unless) ||
                    ((string.IsNullOrEmpty(stdout) || !stdout.Contains(unless)) &&
                     (string.IsNullOrEmpty(stderr) || !stderr.Contains(unless))))
                {
                    ClearPending();
                    FailExitCode(startInfo.FileName, startInfo.Arguments, stdout, stderr, exitCode);
                }
            }
            return exitCode == 0;
        }

        private static void FailExitCode(string exec, string args, string stdout, string stderr, int exitCode)
        {
            throw new ProcessExitException(
                string.Format("accurev returned exit code {0}", exitCode),
                exec, args, stdout, stderr);
        }

        private int Execute(ProcessStartInfo startInfo, out string stdout, out string stderr)
        {
            logger.WriteLine("Executing: {0} {1}", startInfo.FileName, startInfo.Arguments);
#if false
            stdout = "";
            stderr = "";
            return 0;
#endif
            stopwatch.Start();
            try
            {
                using (var process = Process.Start(startInfo))
                {
                    process.StandardInput.Close();
                    var stdoutReader = new AsyncLineReader(process.StandardOutput.BaseStream);
                    var stderrReader = new AsyncLineReader(process.StandardError.BaseStream);

                    var activityEvent = new ManualResetEvent(false);
                    EventHandler activityHandler = delegate { activityEvent.Set(); };
                    process.Exited += activityHandler;
                    stdoutReader.DataReceived += activityHandler;
                    stderrReader.DataReceived += activityHandler;

                    var stdoutBuffer = new StringBuilder();
                    var stderrBuffer = new StringBuilder();
                    while (true)
                    {
                        activityEvent.Reset();

                        while (true)
                        {
                            string line = stdoutReader.ReadLine();
                            if (line != null)
                            {
                                line = line.TrimEnd();
                                if (stdoutBuffer.Length > 0)
                                {
                                    stdoutBuffer.AppendLine();
                                }
                                stdoutBuffer.Append(line);
                                logger.Write('>');
                            }
                            else
                            {
                                line = stderrReader.ReadLine();
                                if (line != null)
                                {
                                    line = line.TrimEnd();
                                    if (stderrBuffer.Length > 0)
                                    {
                                        stderrBuffer.AppendLine();
                                    }
                                    stderrBuffer.Append(line);
                                    logger.Write('!');
                                }
                                else
                                {
                                    break;
                                }
                            }
                            logger.WriteLine(line);
                        }

                        if (process.HasExited)
                        {
                            break;
                        }

                        activityEvent.WaitOne(1000);
                    }

                    stdout = stdoutBuffer.ToString();
                    stderr = stderrBuffer.ToString();
                    return process.ExitCode;
                }
            }
            catch (FileNotFoundException e)
            {
                throw new ProcessException("Executable not found.",
                    e, startInfo.FileName, startInfo.Arguments);
            }
            catch (Win32Exception e)
            {
                throw new ProcessException("Error executing external process.",
                    e, startInfo.FileName, startInfo.Arguments);
            }
            finally
            {
                stopwatch.Stop();
            }
        }

        private bool FindInPathVar(string filename, out string foundPath)
        {
            string path = Environment.GetEnvironmentVariable("PATH");
            if (!string.IsNullOrEmpty(path))
            {
                return FindInPaths(filename, path.Split(Path.PathSeparator), out foundPath);
            }
            foundPath = null;
            return false;
        }

        private bool FindInPaths(string filename, IEnumerable<string> searchPaths, out string foundPath)
        {
            foreach (string searchPath in searchPaths)
            {
                string path = Path.Combine(searchPath, filename);
                if (File.Exists(path))
                {
                    foundPath = path;
                    return true;
                }
            }
            foundPath = null;
            return false;
        }

        private const char QuoteChar = '"';
        private const char EscapeChar = '\\';

        /// <summary>
        /// Puts quotes around a command-line argument if it includes whitespace
        /// or quotes.
        /// </summary>
        /// <remarks>
        /// There are two things going on in this method: quoting and escaping.
        /// Quoting puts the entire string in quotes, whereas escaping is per-
        /// character. Quoting happens only if necessary, when whitespace or a
        /// quote is encountered somewhere in the string, and escaping happens
        /// only within quoting. Spaces don't need escaping, since that's what
        /// the quotes are for. Slashes don't need escaping because apparently a
        /// backslash is only interpreted as an escape when it precedes a quote.
        /// Otherwise both slash and backslash are just interpreted as directory
        /// separators.
        /// </remarks>
        /// <param name="arg">A command-line argument to quote.</param>
        /// <returns>The given argument, possibly in quotes, with internal
        /// quotes escaped with backslashes.</returns>
        private string Quote(string arg)
        {
            if (string.IsNullOrEmpty(arg))
            {
                return "\"\"";
            }

            StringBuilder buf = null;
            for (int i = 0; i < arg.Length; ++i)
            {
                char c = arg[i];
                if (buf == null && NeedsQuoting(c))
                {
                    buf = new StringBuilder(arg.Length + 2);
                    buf.Append(QuoteChar);
                    buf.Append(arg, 0, i);
                }
                if (buf != null)
                {
                    if (c == QuoteChar)
                    {
                        buf.Append(EscapeChar);
                    }
                    buf.Append(c);
                }
            }
            if (buf != null)
            {
                buf.Append(QuoteChar);
                return buf.ToString();
            }
            return arg;
        }

        private bool NeedsQuoting(char c)
        {
            return char.IsWhiteSpace(c) || c == QuoteChar ||
                (shellQuoting && (c == '&' || c == '|' || c == '<' || c == '>' || c == '^' || c == '%'));
        }

        private bool IsUnicode(string file)
        {
            if (!System.IO.File.Exists(file))
                return false;
            using (var f = new System.IO.FileStream(file, FileMode.Open, FileAccess.Read))
            {
                if (f.Length >= 2)
                {
                    byte[] bom = { 0, 0 };
                    f.Read(bom, 0, 2);
                    f.Close();
                    if ((bom[0] == 255) && (bom[1] == 254))
                        return true;
                    else
                        return false;
                }
                else
                {
                    f.Close();
                    return false;
                }
            }
        }
    }
}
